Skip to content
Merged
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
35 changes: 25 additions & 10 deletions specs/01-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ referred to by package name.

**(v2) Adding an app (Drive and the Slides skeleton in M8)** follows the same steps: create the
Flutter app in the workspace (`resolution: workspace`, `lib/driver.dart`), add its `webApps`
entry, its `FlutterRoute`, its `flutter_apps` alias and port (§2.4), its origin to
entry, its `FlutterAppRoute` (via `webApps`), its `flutter_apps` alias and port (§2.4), its origin to
`allowedOrigins`, its build to `flutter_build`, its CI matrix entry and dependency-rule row
(§2.1), its web icons and favicon (02 §5; desktop icons only so the desktop target compiles,
A13), its `WonkyAppKind` value and `config.json` URL, and its lines in `AGENTS.md`. The package
Expand Down Expand Up @@ -334,6 +334,7 @@ controls: toasts for people, none for agents), `WonkyFindBar`, `showConflictRevi
| `wonky_server` | `image` | Drive upload previews and thumbnails (Drive 02 §6) |
| `wonky_server` | `mime` | MIME sniffing of uploaded files (Drive 02 §5, suite 13 §6) |
| `wonky_server` | `dart_jsonwebtoken` | RS256 JWT of the Bugs & improvements GitHub App (specs/16 §13.3) |
| `wonky_server` | `whiskers` | The app shells' templates (`AppShellRoute`, §2.4); Serverpod's template engine (already a dependency of `serverpod`), whose registry only loads `web/templates` |
| `wonky_ui` | `flutter_svg` | Empty-state illustrations (already a dependency of `shad`) |
| `wonky_ui` | `flutter_test` | `pumpWonky` in `lib/testing.dart` (15 §7) |
| `wonky_rich_editor` | `flutter_test` | `enterMarkdown` / `typeMarkdown` in `lib/testing.dart` (15 §7) |
Expand Down Expand Up @@ -445,14 +446,15 @@ development) from one origin; there is no separate host app.

| Path | Served from | Notes |
|------|-------------|-------|
| `/drive/…` (v2) | `web/drive/` — `wonky_drive` built with `--base-href /drive/` | `FlutterRoute` (SPA fallback to `index.html`) |
| `/write/…` | `web/write/` — `wonky_write` built with `--base-href /write/` | `FlutterRoute` (SPA fallback to `index.html`) |
| `/sheets/…` | `web/sheets/` — `wonky_sheets` built with `--base-href /sheets/` | `FlutterRoute` |
| `/slides/…` (v2) | `web/slides/` — `wonky_slides` built with `--base-href /slides/` | `FlutterRoute` |
| `/drive/…` (v2) | `web/drive/` — `wonky_drive` built with `--base-href /drive/` | `FlutterAppRoute` (SPA fallback to `index.html`, the shell rendered as a template) |
| `/write/…` | `web/write/` — `wonky_write` built with `--base-href /write/` | `FlutterAppRoute` (SPA fallback to `index.html`, the shell rendered as a template) |
| `/sheets/…` | `web/sheets/` — `wonky_sheets` built with `--base-href /sheets/` | `FlutterAppRoute` |
| `/slides/…` (v2) | `web/slides/` — `wonky_slides` built with `--base-href /slides/` | `FlutterAppRoute` |
| `/<app>/**/sqlite3.wasm`, `/<app>/**/db_worker.js` (nested paths) | `sqliteAssetAlias` middleware | Serves the app's root copies: when `sqlite3_web` opens a database in the page (its worker failed) it resolves the relative default URIs of `sqlite_async` (set inside `serverpod_database`, not configurable) against the page URL, e.g. `/sheets/s/sqlite3.wasm`, which the SPA fallback answered with `index.html` and the local replica could not open |
| `/<app>/assets/assets/config.json` (each app) | `AppConfigRoute` | Replaces the bundled `assets/config.json` at runtime: `{apiUrl, webUrl, apps: {drive, write, sheets, slides}, cookieAuth, google?: {clientId, redirectUri}}` (the API URL — `wonky.publicApiUrl` / `WONKY_PUBLIC_API_URL`, default `https://api.wonky.work` in production (§2.5), else the API server's public host —, the public web origin for the legal pages, the base URL of each app for cross-app links, and Google sign-in, 04 §1.6). The public web origin is `wonky.publicWebUrl` / `WONKY_PUBLIC_WEB_URL`, default `https://wonky.work` in production (Serverpod Cloud generates the server config, so its own `*.serverpod.space` host is never used in links), else the web server's public host; e-mail and MCP links use it too |
| `/auth/callback` | `FlutterWebAuth2CallbackRoute` | OAuth popup callback for Sign in with Google (04 §1.6) |
| `/privacy`, `/terms` | `StaticRoute.file` | Privacy policy and terms of service (`web/pages/privacy.html`, `terms.html`; suite 13 §5) |
| `/web/…` | `StaticRoute.withCacheBusting` (`cacheBustingConfig`) | `web/static/`, `public, immutable, max-age` one year. Pages link these files by their cache-busted URL, `/web/images/og-1@<hash>.jpg` (Relic strips the hash): every page that links one is a template (the legal pages, the app shells) and says `{{{@/web/…}}}`, which `WidgetRoute` resolves with `cacheBustingConfig` (the hash is computed once per file) |
| `/privacy`, `/terms` | `LegalPageRoute` (a `WidgetRoute`) | Privacy policy and terms of service, the templates `web/templates/privacy.html`, `terms.html` (suite 13 §5); their `/web/` URLs are cache-busted |
| `/` | redirect | (v2) Always to **`/drive/`** — Wonky Drive's home, the default landing app (v2 review B4; `RootRedirectRoute`, `rootRedirectTarget`). The `wonky_last_app` cookie is no longer read (the apps may stop setting it) |
| `/mcp`, `/files/…`, `/invite/:token` | server routes | See 09, 05, 06; (v2) `/files/d/:fileUuid/:name` downloads and `PUT /files/upload/:fileUuid` token uploads (MCP P0, chunked P1), Drive 02 §3.3, §4.3, §8 |
| anything else unbuilt | `web/pages/build_flutter_app.html` | Template fallback shown when an app's build folder is missing |
Expand All @@ -476,10 +478,23 @@ development) from one origin; there is no separate host app.
`summary_large_image`). The SPA fallback serves that file for every URL of the app, shared
documents and invites included, so the tags stay **generic: never an item's title, owner or
content**, and there is no `og:url` (it would name another page as the canonical one). The
image is `https://wonky.work/web/images/og-1.jpg` (1200×630 JPEG, under WhatsApp's 300 KB;
source `icons/og.webp`): absolute, as crawlers need, and versioned in its name because
`/web/` is served as immutable — a new image gets a new name. The legal pages carry the
same tags with their own `og:url` and canonical link.
image is `web/static/images/og-1.jpg` (1200×630 JPEG, under WhatsApp's 300 KB; source
`icons/og.webp`), linked by an absolute URL, as crawlers need, that carries its content hash:
`index.html` says `https://wonky.work{{{@/web/images/og-1.jpg}}}`, and the server answers
`https://wonky.work/web/images/og-1@<hash>.jpg`. `/web/` is cached as immutable for a year by
browsers and the CDN, and X and LinkedIn cache an image per URL, so a changed image must get a
new URL; the hash does that without renaming the file. The shell is a template:
`FlutterAppRoute` is Serverpod's `FlutterRoute` (the build's files, the SPA fallback, the WASM
headers) except that `/<app>/`, `/<app>/index.html` and the SPA fallback are `AppShellRoute`, a
`WidgetRoute` with `cacheBustingConfig`, like the legal pages. It parses the build's
`index.html` once (again when the file changes) and answers `private, no-cache` without an
ETag (the shell is about 5 KB). Under `flutter run` the placeholder is served as it is, which
only the crawlers would read; never write `{{` anywhere else in `index.html`. The build's own
files are served without cache busting, which would strip `@…` from asset names such as
`icon@2x.png`. The legal pages carry the same tags (`{{{@/web/images/og-1.jpg}}}` in their
templates) with their own `og:url` and canonical link.
Crawlers also cache each shared page's preview (LinkedIn about 7 days, X about as long): after
a change, re-scrape a URL with LinkedIn's Post Inspector; X refreshes on its own.
- **No Grammarly**: the apps check spelling themselves (`wonky_spellcheck`), and Grammarly's
overlays on Flutter's hidden text fields interfere with editing. Each app's `web/index.html`
opts out: `data-gramm="false" data-gramm_editor="false" data-enable-grammarly="false"` on
Expand Down
1 change: 1 addition & 0 deletions tool/check_dependencies.dart
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ const rules = <String, PackageRule>{
'image': 'Drive upload previews and thumbnails (Drive 02 §6)',
'mime': 'MIME sniffing of uploaded files (Drive 02 §5, suite 13 §6)',
'dart_jsonwebtoken': 'RS256 JWT of the GitHub App (specs/16 §13.3)',
'whiskers': "app shell templates, Serverpod's template engine (§2.4)",
},
),
'wonky_client': PackageRule(
Expand Down
6 changes: 3 additions & 3 deletions wonky_drive/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@
Link previews (Open Graph, X cards; specs/01 §2.4). Every URL of the app,
a shared document's too, serves this file: keep it generic, never per
item. No og:url, so a preview names the link that was shared. The image
URL is absolute and versioned (/web/ is cached as immutable): give a new
image a new name.
URL is absolute; the server renders this file as a template, giving the
image its cache-busted URL, og-1@<hash>.jpg (specs/01 §2.4).
-->
<meta property="og:type" content="website">
<meta property="og:site_name" content="Wonky Work">
<!-- Drive is where wonky.work lands (/ redirects here): the suite's name. -->
<meta property="og:title" content="Wonky Work">
<meta property="og:description" content="Write, Sheets, Slides and Drive: an open-source office suite for working together, built by the community with Flutter and Serverpod.">
<meta property="og:image" content="https://wonky.work/web/images/og-1.jpg">
<meta property="og:image" content="https://wonky.work{{{@/web/images/og-1.jpg}}}">
<meta property="og:image:type" content="image/jpeg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
Expand Down
13 changes: 7 additions & 6 deletions wonky_server/lib/server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ import 'src/sheets/versions/sheets_version_provider.dart';
import 'src/slides/versions/slides_version_provider.dart';
import 'src/web/middleware/sqlite_asset_alias.dart';
import 'src/web/routes/app_config_route.dart';
import 'src/web/routes/flutter_app_route.dart';
import 'src/web/routes/github_webhook_route.dart';
import 'src/web/routes/improvements_redirect_route.dart';
import 'src/web/routes/invite_route.dart';
import 'src/web/routes/legal_page_route.dart';
import 'src/web/routes/publication_api_route.dart';
import 'src/web/routes/root_redirect_route.dart';
import 'src/web/web_apps.dart';
Expand Down Expand Up @@ -160,12 +162,10 @@ void _configureWebServer(Serverpod pod) {
// Sign in with Google on web (specs/04 §1.6): the OAuth2 redirect lands
// here and hands the code back to the app's sign-in popup.
pod.webServer.addRoute(FlutterWebAuth2CallbackRoute(), '/auth/callback');
// Legal pages (specs/13 §5), linked from account creation.
// Legal pages (specs/13 §5), linked from account creation: templates, so
// their /web/ asset URLs are cache-busted.
for (final page in const ['privacy', 'terms']) {
pod.webServer.addRoute(
StaticRoute.file(File(Uri(path: 'web/pages/$page.html').toFilePath())),
'/$page',
);
pod.webServer.addRoute(LegalPageRoute(page), '/$page');
}
pod.webServer.addRoute(InviteRoute(), '/invite/:token');
// Bugs & improvements links (specs/16 §4.3): `/improvements/**` opens
Expand Down Expand Up @@ -201,7 +201,8 @@ void _configureWebServer(Serverpod pod) {
final dir = Directory(Uri(path: 'web/$app').toFilePath());
if (dir.existsSync()) {
// Built with --wasm: cross-origin isolation headers (specs/13 §3).
pod.webServer.addRoute(FlutterRoute(dir), '/$app');
// The shell, index.html, is a template with cache-busted /web/ URLs.
pod.webServer.addRoute(FlutterAppRoute(dir), '/$app');
// sqlite3.wasm asked for relative to a deep link (`/sheets/s/…`).
pod.webServer.addMiddleware(sqliteAssetAlias(app, dir), '/$app');
} else {
Expand Down
94 changes: 94 additions & 0 deletions wonky_server/lib/src/web/routes/flutter_app_route.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import 'dart:io';

import 'package:serverpod/serverpod.dart';
import 'package:whiskers/whiskers.dart';

import '../../cache_busting.dart';

/// A Flutter web app (specs/01-architecture.md §2.4): Serverpod's
/// [FlutterRoute] (the build's files, the SPA fallback, the WASM headers),
/// except that the shell, `index.html`, is a template served by
/// [AppShellRoute], so its `{{{@/web/…}}}` URLs are cache-busted like the
/// other pages'.
class FlutterAppRoute extends FlutterRoute {
FlutterAppRoute(super.directory);

@override
void injectIn(RelicRouter router) {
final shell = AppShellRoute(indexFile);
final subRouter = Router<Handler>();
if (enableWasmHeaders) {
subRouter.use('/', const WasmHeadersMiddleware().call);
}
// App URLs (`/write/d/…`) aren't files: they get the shell.
subRouter.use(
'/',
FallbackMiddleware(
fallback: shell,
on: (response) => response.statusCode == 404,
).call,
);
// Literal paths win over the static files' `/**`.
for (final path in const ['/', '/index.html']) {
subRouter.anyOf(shell.methods, path, shell.asHandler);
}
// No cache busting for the build's own files: it would strip `@…` from
// asset names such as `icon@2x.png`.
StaticRoute.directory(
directory,
cacheControlFactory: cacheControlFactory,
).injectIn(subRouter);
router.attach('/', subRouter);
}
}

/// An app's shell, the template [indexFile] (the build's `index.html`),
/// rendered with the suite's [cacheBustingConfig]: the link-preview image
/// `https://wonky.work{{{@/web/images/og-1.jpg}}}` becomes
/// `https://wonky.work/web/images/og-1@<hash>.jpg`, the URL `/web/` serves as
/// immutable, so the CDN and the link-preview caches of X and LinkedIn see a
/// new URL when the image changes.
///
/// The template is parsed once and again when the file changes (a new build
/// while the server runs); `WidgetRoute` answers `private, no-cache`.
class AppShellRoute extends WidgetRoute {
AppShellRoute(this.indexFile)
: super(
methods: {Method.get, Method.head},
cacheBustingConfig: cacheBustingConfig,
);

/// The build's `index.html`.
final File indexFile;

Template? _template;
DateTime? _modified;

@override
Future<WebWidget> build(Session session, Request request) async {
final modified = (await indexFile.stat()).modified;
if (_template == null || modified != _modified) {
_template = Template(
await indexFile.readAsString(),
name: indexFile.path,
lenient: true,
);
_modified = modified;
}
return _ShellWidget(_template!);
}
}

/// Renders the shell's template; it has no values, only `{{{@/web/…}}}`.
class _ShellWidget extends WebWidget {
_ShellWidget(this.template);

final Template template;

@override
String render({String? Function(String)? onMissingVariable}) =>
template.renderString(
const {},
onMissingVariable: (name, context) => onMissingVariable?.call(name),
);
}
22 changes: 22 additions & 0 deletions wonky_server/lib/src/web/routes/legal_page_route.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import 'package:serverpod/serverpod.dart';

import '../../cache_busting.dart';

/// A legal page (specs/13 §5), the template `web/templates/<name>.html`.
/// Its `{{{@/web/…}}}` asset URLs get their content hash
/// (`og-1@<hash>.jpg`), so the link-preview image and the icon are cached
/// per version by the CDN and by the X and LinkedIn crawlers (specs/01 §2.4).
class LegalPageRoute extends WidgetRoute {
LegalPageRoute(this.name)
: super(
methods: {Method.get, Method.head},
cacheBustingConfig: cacheBustingConfig,
);

/// The template's name: `privacy` or `terms`.
final String name;

@override
Future<WebWidget> build(Session session, Request request) async =>
TemplateWidget(name: name);
}
1 change: 1 addition & 0 deletions wonky_server/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ dependencies:
serverpod: 4.1.0-beta.3
serverpod_auth_idp_server: 4.1.0-beta.3
serverpod_cloud_storage: 4.1.0-beta.3
whiskers: ^0.1.1
wonky_core:
path: ../packages/wonky_core
wonky_sheets_core:
Expand Down
110 changes: 110 additions & 0 deletions wonky_server/test/integration/web/flutter_app_route_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
@Tags(['integration'])
library;

import 'dart:io';

import 'package:test/test.dart';
import 'package:wonky_server/src/cache_busting.dart';
import 'package:wonky_server/src/web/routes/flutter_app_route.dart';

import '../../support/wonky_serverpod.dart';

/// The app shell rendered as a template (specs/01 §2.4): its
/// `{{{@/web/…}}}` URLs carry the asset's content hash, the build's own
/// files are served as they are.
void main() {
withWonkyServerpod('Given a Flutter app served by FlutterAppRoute', (
sessionBuilder,
endpoints,
) {
final client = HttpClient();
late Directory build;
late int port;
late String image;

setUpAll(() async {
build = Directory.systemTemp.createTempSync('wonky_app_');
File('${build.path}/index.html').writeAsStringSync(
'<!DOCTYPE html><html><head><base href="/testapp/">'
'<meta property="og:image" '
'content="https://wonky.work{{{@/web/images/og-1.jpg}}}">'
'</head><body><script src="main.dart.js"></script></body></html>',
);
File('${build.path}/main.dart.js').writeAsStringSync('// app');
File('${build.path}/assets/icon@2x.png')
..createSync(recursive: true)
..writeAsBytesSync([1, 2, 3]);
image = await cacheBustingConfig.assetPath('/web/images/og-1.jpg');

final pod = sessionBuilder.build().serverpod;
pod.webServer.addRoute(FlutterAppRoute(build), '/testapp');
if (!pod.webServer.running) await pod.webServer.start();
port = pod.webServer.port!;
});
tearDownAll(() {
client.close(force: true);
build.deleteSync(recursive: true);
});

Future<(HttpClientResponse, String)> fetch(
String path, {
String method = 'GET',
}) async {
final request = await client.open(method, 'localhost', port, path);
final response = await request.close();
final body = await response
.transform(const SystemEncoding().decoder)
.join();
return (response, body);
}

for (final path in const [
'/testapp/',
'/testapp/index.html',
'/testapp/d/0192a3b4-1111-7000-8000-000000000001',
]) {
test('when GET $path then the shell links the image by its '
'cache-busted URL', () async {
final (response, body) = await fetch(path);

expect(response.statusCode, HttpStatus.ok);
expect(response.headers.contentType?.mimeType, 'text/html');
expect(image, matches(RegExp(r'^/web/images/og-1@[0-9a-f]+\.jpg$')));
expect(body, contains('content="https://wonky.work$image"'));
expect(body, isNot(contains('{{')));
expect(
response.headers.value('cross-origin-embedder-policy'),
'require-corp',
);
});
}

test('when HEAD the shell then it answers 200 without a body', () async {
final (response, body) = await fetch('/testapp/', method: 'HEAD');

expect(response.statusCode, HttpStatus.ok);
expect(body, isEmpty);
});

test('when GET a build file then it is served as it is', () async {
final (response, body) = await fetch('/testapp/main.dart.js');

expect(response.statusCode, HttpStatus.ok);
expect(body, '// app');
});

test('when GET an asset with @ in its name then it is not stripped as a '
'cache-busting hash', () async {
final request = await client.get(
'localhost',
port,
'/testapp/assets/icon@2x.png',
);
final response = await request.close();
final bytes = await response.expand((b) => b).toList();

expect(response.statusCode, HttpStatus.ok);
expect(bytes, [1, 2, 3]);
});
});
}
Loading
Loading