-
Notifications
You must be signed in to change notification settings - Fork 313
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
Declarative routing #1373
Comments
Here's an alternative model suggested by @wanderview, which is focused on allowing developers to toggle the fetch event: partial interface ServiceWorkerGlobalScope {
attribute ServiceWorkerEventSubscriptions eventSubscriptions;
}
interface ServiceWorkerEventSubscriptions {
Array<DOMString> get();
void add(DOMString eventType);
void remove(DOMString eventType);
} Where
This means the developer would be able to expire their fetch handler after some amount of time (I believe this is Facebook's use-case). const deployTimestamp = 1543938484103;
const oneDay = 1000 * 60 * 60 * 24;
addEventListener('fetch', (event) => {
if (Date.now() - deployTimestamp > oneDay) {
eventSubscriptions.remove('fetch');
return;
}
// …
}); This is a much simpler feature, but it doesn't allow skipping the service worker by route, or going straight to the cache by route. |
One more suggestion (again from @wanderview) would be to add a new CSP rule (or something similar) that defines which url-prefixes a service worker would intercept, if any. This means the setting would be page-by-page. Navigations wouldn't be able to opt-out of service worker, but presumably navigation preload would solve a lot of performance issues there. |
There's a gotcha with the static routes: If you put them at top level, they'll execute without issue, but next time the service worker is started (after activating), they'll fail. Also, if the service worker starts multiple times before activation, you'll get duplicate routes. Might make more sense to throw unless the routes are being added during the install event, so they'd always fail top-level. Given that, it might make sense to put the API on the install event. Edit: I've moved it to the install event. |
How does this relate to https://github.com/domenic/import-maps? Intuitively it feels like these should be the same thing. |
Just to clarify, the two suggestions I had were to support a subset of the use cases where the site might want to stop consulting the fetch handler until an update can occur. Some sites are blocked on these use cases right now and I wondered if there was a way we could unblock them without conflicting with any future possible static routes spec. |
@annevk I think they're different enough, and can be used together. Import maps URLs with scheme Then, the service worker's fetch event (or its static routes) can decide how to conduct those fetches. |
I can see developers eagerly adopting this approach if browsers started to implement it. Being able to string together complex routing rules with network/cache interactions without the overhead of starting up a service worker or pulling in extra runtime code (except for the polyfill scenario...) would be a nice performance win. I have something of a meta-question about the longer term, v2+, plans though. There are some common concerns that production web apps need to think about, like cache maintenance/expiration policies for runtime caches. Today, that type of activity is likely to happen inside of a a) fast routing/response generation, but no developers might feel conflicted about that tradeoff. One approach could be to create a new type of event that's fired after a response has been generated by the native router, like Looking at it from a slightly different perspective, this proposal ends up creating a native implementation of a subset of things that have up until now been accomplished using service worker runtime libraries. Cache-expiration also falls into the category of things that developers commonly accomplish via service worker runtime libraries. The feeling I got from #863 is that there hasn't been an appetite for natively implementing that as part of the Cache Storage API. If this routing proposal moves forward, does that change the equation around natively implementing higher-level functionality like cache expiration? |
That's a good point. I had this in earlier drafts but didn't think we needed it yet. Should be relatively simple to add. |
This is awesome! @jakearchibald, I really like the proposal you outlined outlined in the first comment, it solves the two big issues that we're seeing 😃
@jakearchibald, @wanderview's solution that you outlined in the second comment above gives us a solution to issue 2 but not issue 1. Like @wanderview said in his comment, issue 2 impacts us more right now than issue 1. So with the first proposal, as long as the spec is written in such a way that vendors can quickly implement the experary time condition without having to block on implementing the RouterIfURL conditions then I think this is really really great! |
@n8schloss thanks for the feedback! It feels like @jatindersmann, @aliams, @youennf, @asutherland, @mattto, @wanderview: How do you feel about this implementation-wise? |
@jakearchibald - This is really great! I like the flexibility of the proposal. I’m interested in understanding better if this will extend to more complex scenarios in the future.
Also one thought about implementation - if a request is handled via a static route, would the service worker still be started up in the background even though it isn't needed for the request? On the one hand, this would ensure that the service worker is started for future resource requests. On the other hand, if an entire page load (main resource plus sub resources) can be handled by static routes, then it's nice to avoid the performance cost of starting up the service worker. |
I think I'd make this an option to
Something like: router.get(
new RouterSourceAny([
...sources
])
); This could also have a timeout option.
I think everything you mention here is possible, although we might be hitting edge cases that are easier to read as logic in a fetch event, and uncommon enough that optimising them via a router doesn't bring much of a benefit.
I don't think it should. Only things you can currently do in a fetch event are in scope. To do this, we either want a way to exclude routes as part of the call to
Interesting! The spec is deliberately loose when it comes to when the service worker is started, and how long it stays alive for. Either behaviour would be spec compatible. If a service worker is started, and not needed, it shouldn't affect page performance as nothing's blocked on it. |
It might also be useful to describe the default routes you get when a service worker is installed. Either route to FetchEvent or no where depending on if there is a fetch handler.
Personal opinion that does not represent any actual implementation priorities: I guess I'm most interested in how something like this could be incrementally approached. For example, if we started with:
Or replace (2) with To me the I imagine, though, there is going to be a tension between how many of these options and extensions to implement vs using javascript in FetchEvent. For example, the list of logical combinations in the "extensibility" section seemed like perhaps something that should just be done in js. I'm not sure we want to implement and maintain a complex DSL when we can achieve the same thing with js. |
One thing that isn't clear to me yet: router.add(
new RouterIfURLPrefix('/avatars/'),
new RouterSourceCache(),
);
router.add(
new RouterIfURLSuffix('.jpg'),
new RouterSourceNetwork(),
); If |
Having slept on it, I think it's important that a single route is selected based on conditions. I'll work on a new draft that uses a This matches other routers like Express, where "continue to other routes" is opt-in. |
Ok, here's a second draft: Creating a routeWebIDL // Install currently uses a plain ExtendableEvent, so we'd need something specific
partial interface ServiceWorkerInstallEvent {
attribute ServiceWorkerRouter router;
}
[Exposed=ServiceWorker]
interface ServiceWorkerRouter {
void add(
(RouterCondition or sequence<RouterCondition>) conditions,
(RouterSource or sequence<RouterSource>) sources,
);
}
[Exposed=ServiceWorker]
interface RouterSource {}
[Exposed=ServiceWorker]
interface RouterCondition {} JavaScript addEventListener('install', (event) => {
event.router.add(conditions, sources);
event.router.add(otherConditions, otherSources);
}); The browser will consider routes in the order declared, and if all conditions match, each source will be tried in turn. ConditionsThese determine if a particular static route should be used rather than dispatching a fetch event. WebIDL [Exposed=ServiceWorker, Constructor(ByteString method)]
interface RouterIfMethod : RouterCondition {}
[Exposed=ServiceWorker, Constructor(USVString url, optional RouterIfURLOptions options)]
interface RouterIfURL : RouterCondition {}
dictionary RouterIfURLOptions {
boolean ignoreSearch = false;
}
[Exposed=ServiceWorker, Constructor(USVString url)]
interface RouterIfURLStarts : RouterCondition {}
[Exposed=ServiceWorker, Constructor(USVString url, optional RouterIfURLOptions options)]
interface RouterIfURLEnds : RouterCondition {}
[Exposed=ServiceWorker, Constructor(optional RouterIfDateOptions options)]
interface RouterIfDate : RouterCondition {}
dictionary RouterIfDateOptions {
// These should accept Date objects too, but I'm not sure how to do that in WebIDL.
unsigned long long from = 0;
// I think Infinity is an invalid value here, but you get the point.
unsigned long long to = Infinity;
}
[Exposed=ServiceWorker, Constructor(optional RouterIfRequestOptions options)]
interface RouterIfRequest : RouterCondition {}
dictionary RouterIfRequestOptions {
RequestDestination destination;
RequestMode mode;
RequestCredentials credentials;
RequestCache cache;
RequestRedirect redirect;
} Again, these interfaces don't have attributes, but they could reflect the options/defaults passed into the constructor. SourcesThese determine where the route should try to get a response from. WebIDL [Exposed=ServiceWorker, Constructor(optional RouterSourceNetworkOptions options)]
interface RouterSourceNetwork : RouterSource {}
dictionary RouterSourceNetworkOptions {
// A specific request can be provided, otherwise the current request is used.
Request request;
// Reject responses that do not have an ok status.
boolean requireOkStatus;
}
[Exposed=ServiceWorker, Constructor(optional RouterSourceCacheOptions options)]
interface RouterSourceCache : RouterSource {}
RouterSourceCacheOptions : MultiCacheQueryOptions {
// A specific request can be provided, otherwise the current request is used.
Request request;
}
[Exposed=ServiceWorker, Constructor(optional RouterSourceFetchEventOptions options)]
interface RouterSourceFetchEvent : RouterSource {}
dictionary RouterSourceFetchEventOptions {
DOMString id = '';
} These interfaces don't currently have attributes, but they could have attributes that reflect the options/defaults passed into the constructor. ShortcutsGET requests are the most common type of request to provide specific routing for. WebIDL partial interface ServiceWorkerRouter {
void get(/* same as add */);
} Where the JavaScript implementation is roughly: router.get = function(conditions, sources) {
if (conditions instanceof RouterCondition) {
conditions = [conditions];
}
router.add([new RouterIfMethod('GET'), ...conditions], sources);
}; We may also consider treating strings as URL matchers.
ExamplesBypassing the service worker for particular resourcesJavaScript // Go straight to the network after 25 hrs.
router.add(
new RouterIfDate({ from: Date.now() + 1000 * 60 * 60 * 25 }),
new RouterSourceNetwork(),
);
// Go straight to the network for all same-origin URLs starting '/videos/'.
router.add(
new RouterIfURLStarts('/videos/'),
new RouterSourceNetwork(),
); Offline-firstJavaScript router.get(
// If the URL is same-origin and starts '/avatars/'.
new RouterIfURLStarts('/avatars/'),
[
// Try to get a match for the request from the cache.
new RouterSourceCache(),
// Otherwise, try to fetch the request from the network.
new RouterSourceNetwork(),
// Otherwise, try to get a match for the request from the cache for '/avatars/fallback.png'.
new RouterSourceCache({ request: '/avatars/fallback.png' }),
],
); Online-firstJavaScript router.get(
// If the URL is same-origin and starts '/articles/'.
new RouterIfURLStarts('/articles/'),
[
// Try to fetch the request from the network.
new RouterSourceNetwork(),
// Otherwise, try to match the request in the cache.
new RouterSourceCache(),
// Otherwise, try to match '/articles/offline' in the cache.
new RouterSourceCache({ request: '/articles/offline' }),
],
); ProcessingThis is very rough prose, but hopefully it explains the order of things. A service worker has routes. The routes do not belong to the registration, so a new empty service worker will have no defined routes, even if the previous service worker defined many. A route has conditions and sources. To create a new route containing conditions and sources
Handling a fetchThese steps will come before handling navigation preload, meaning no preload will be made if a route handles the request. request is the request being made.
ExtensibilityI can imagine things like:
But these could arrive much later. Some of the things in the main proposal may also be considered "v2". |
Here's a couple of things that, based on what we've run into with Workbox's routing, tend to crop up in the real-world. I wanted to draw them to your attention so that you could think about them earlier rather than later. Cross-origin routingDevelopers sometimes want to route cross-origin requests. And sometimes they don't. Coming up with a syntax for Workbox has a few different ways of specifying routing conditions, but the most common is I would imagine folks wanting to see at least a Non-200 OK responses sometimes need to be treated as errorsThe proposal for I'm not sure what the cleanest approach is there in terms of your proposal—maybe adding in a way of setting a list of "acceptable" status codes, including letting folks opt-in or opt-out to status code |
I've renamed prefix/suffix to starts/ends to match |
I wrote https://jakearchibald.com/2019/service-worker-declarative-router/ to seek wider feedback. Also see my tweet about it for replies. |
router.add(
[
new RouterIfURLStarts('https://photos.example.com/'),
new RouterIfURLEnds('.jpg', { ignoreSearch: true }),
]
// …
); The above would match on URLs that start RegExp is tricky here as there's no spec for how it could outlive JavaScript. We could try and standardise globbing, but I worry that would take a big chunk of time.
I've added |
Hi @jakearchibald, this is really interesting! Reading the post on your blog, I wondered how I could add the IMHO, this would be great in the "v1" to help build offline experiences without manually preloading. But I understand there must be some priorities. 😅 |
Yeah, we want to avoid trying to do everything at once. In terms of fetching and caching, see https://jakearchibald.com/2019/service-worker-declarative-router/#routersourcefetchevent. |
This is not going to be a popular view. but I am not a massive fan of the 'string' shortcut of the conditions. I understand you want it to be as close to existing apis like express.. but not everyone understands express all the time. I wondered if there could be some optimisation for that, behind the scenes. Since they all have to be So ones that are a little more expensive to calculate could be done last, and super dirty cheap ones could be done first? Rather than For example, router.add(
[
new RouterIfURLMatchesRegex('lets assume some horrible regex'),
new RouterIfMethod('GET'),
]
// …
); I know |
I see what you're saying about the string thing. Also, it isn't all that similar to express. It might be better to drop that shortcut until something like globbing can be properly spec'd.
In the case of multiple conditions, I'd spec them as a sequence, but if a browser decided to check them in a different order, or in parallel, there'd be no observable difference. |
Some feedback I've received offline: The string shortcut might lead developers to think something like When |
My feedback:
|
Really liking the concept of avoiding the overhead of starting the ServiceWorker where possible, but I feel like not adding an additional Source type that utilises a callback is a missed opportunity. It would effectively be the same behaviour as the fetch event fallback, but specific to the given conditions. For all other source types we would still avoid starting up the service worker. Also I think syntax wise I feel it reads better to have static methods on a RouterCondition/RouterSource class that instantiate the specific type. Although I'm not sure how in keeping that is with other web APIs. const { router } = e;
router.get(
RouterCondition.startsWith("/avatars/"),
[ RouterSource.cache(), RouterSource.network() ]
);
router.get(
RouterCondition.fileExtension(".mp4"),
RouterSource.network()
);
router.get(
RouterCondition.URL("/"),
RouterSource.cache("/shell.html")
);
router.add(
RouterCondition.any(),
RouterSource.custom(fetchEvent => {
})
); |
@yoavweiss: I think the |
I apologize that this is not a very substantial contribution, but I think I'm having an allergic reaction to the "Java-esque" (or Dart-esque) nested class constructors. I'd encourage thinking about what this design would look like if done in the opposite direction: a purely JSON format. For example, something like router.route([
{
condition: {
method: "GET",
urlStartsWith: "/avatars"
},
destination: ["cache", "network"]
},
{
condition: {
method: "GET",
urlPathEndsWith: ".mp4"
}
destination: ["network"]
},
{
condition: {
method: "GET",
urlPath: "/"
},
destination: [{ cache: "/shell.html" }]
}
]); I don't think this extreme is right either (in particular the Another way to think about this is, when are these types ever useful? If they're only ever consumed by the router system, and not manipulated, composed, or accessed by users, then perhaps it's better to just pass the data they encapsulate directly to the system. (That data is roughly a "type tag" plus their constructor arguments.) |
@WORMSS Yeah it's called that in the URL object too. It's called query inside the spec language but now that I re-read it, I realised that the word "query" never appears in the API interface itself. So "search" is fine. |
Curious whether we could use @wanderview's URLPattern proposal instead of |
FYI, we are about to implement a subset of the API. |
Thanks for your work! |
Should we allow registerRouter() to be called multiple times?As commented in #1373 (comment), we are about to implement a subset, as ServiceWorker Static Routing API. We (Google Chrome team) are ready for the Origin Trial, it's available from M116. In the meantime, we'd like to hear opinions around how Unlike add() or get() in the original Jake's proposal, Like Speed Kit, some companies provide ServiceWorker scripts as SDK, and customer websites use their scripts via importScripts(). If both a 3rd party provider and a customer want to use registerRouter(), in our current implementation the browser only registers the routing info called for the first time, and throws type error for the second registerRouter() call. I personally feel it makes sense that 3rd parties use registerRouter() to handle something, but do you think we should support multiple registerRouter() calls? If so, a naive approach is just adding a new routing rule into existing rules, but do we need additional mechanisms or API surfaces to manage registered routes more smartly? cc: @ErikWitt |
I think allowing multiple calls is fine. In that case I would rename the method from The naive approach makes perfect sense to me. In other words, |
Given that, is there a benefit to |
Heythere, sorry for being so late to the discussion. My two cents:
btw. I love the use of URLPattern in the api :) Looking forward to trying this in production and reporting back on the performance gains |
Thank you all for the feedback! @jakearchibald That's a fair point. WDYT @yoshisatoyanagisawa ?
At the moment we want to keep it inside the install event. We don't want to make the API dynamically configurable. If you have a workaround please consider doing so.
OriginTrial for ServiceWorker features is running on a bit different mechanism, and unfortunately 3rd party origin trial is not supported yet. We understood the use case from 3rd party context. Let us consider how to deal with it, but please expect it takes some time to solve. |
I came up with this scenario, perhaps it can be a problem for some cases. a.com/sw.js: importScripts('b.com/imported-sw.js');
addEventListener('install', (event) => {
event.registerRouter({
condition: {
// example.jpg is returned form the fetch handler.
urlPattern: {pathname: "example.jpg"}
},
source: "fetch-event"
});
}) b.com/imported-sw.js: addEventListener('install', (event) => {
event.registerRouter({
condition: {
// All jpg resources are fetched from the network.
urlPattern: {pathname: "*.jpg"}
},
source: "network"
});
})
Do you think the API should have a mechanism to address this case which is introduced by allowing multiple |
I think this behavior is WAI. If you call If you would like to have your own code take priority, then you should rearrange addEventListener('install', (event) => {
event.registerRouter(/* ... */);
});
importScripts('b.com/imported-sw.js'); |
It could be I usually don't like that pattern, since it prevents adding new parameters in future, but if seems like any options would be per route.
|
Sorry for being late to the discussion. For the naming of the API, By the way, I have concerns on calling the API multiple times,
all rule updates after the rule would be just ignored. I guess it would be fine to be WAI if the order of rules is guaranteed.
It is intended to make the rule updated only inside the install event.
FYI, https://bugs.chromium.org/p/chromium/issues/detail?id=1471005
Current API accepts both sequence of routes and a route. I suppose you suggest only accepting a route. Will you elaborate more on why you want to prohibit accepting a sequence of rules? |
Just FYI, the issues on the static routing API have been filed in https://github.com/WICG/service-worker-static-routing-api/issues. Please take a look. |
Based on the recent discussion on the Github issue [1], we introduce `addRoutes()` to InstallEvent, which is another way to register routing info. Summary: 1.Unlike `registerRouter()`, `addRoutes()` can be called multiple times. 2.`registerRouter()` remains as it is, at least until OT end date. 3.`addRoutes()` and `registerRouter()` are exclusive. Raises exceptions once both methods are called. [1] w3c/ServiceWorker#1373 (comment) Change-Id: I47b2f26501f86b4a97af44dde4355b66a0483000 Bug: 1468184 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/4729442 Reviewed-by: Takashi Toyoshima <[email protected]> Reviewed-by: Yoshisato Yanagisawa <[email protected]> Commit-Queue: Shunya Shishido <[email protected]> Cr-Commit-Position: refs/heads/main@{#1218684}
Upon WICG/service-worker-static-routing-api#10 and w3c/ServiceWorker#1373 (comment), we decided to use addRoutes() instead of registerRouter() to configure the routes. This CL updates code using registerRouter() to addRoutes(), and make calling registerRouter() to warn the deprecation. Note that this is a follow up CL of: https://chromium-review.googlesource.com/c/chromium/src/+/5233688/comment/abe857f9_5f5d2fdc/ Bug: 1371756 Change-Id: I1b199310a6701b3b5da38e5ddcaf20280b047e24 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/5240149 Reviewed-by: Shunya Shishido <[email protected]> Commit-Queue: Yoshisato Yanagisawa <[email protected]> Cr-Commit-Position: refs/heads/main@{#1253156}
FYI, we are discussing on the way to handle an empty "not" condition in WICG/service-worker-static-routing-api#22. We are leaning on raising. Please feel free to share your opinions. |
FYI, we are currently actively discussing extending the resource timing API to support static routing API. |
Here are the requirements I'm working towards:
I'm going to start with static routes, and provide additional ideas in follow-up posts.
The aim is to allow the developer to declaratively express a series of steps the browser should perform in attempt to get a response.
The rest of this post is superseded by the second draft
Creating a route
WebIDL
JavaScript
The browser will consider routes in the order declared, and will consider route items in the order they're given.
Route items
Route items fall into two categories:
Sources
WebIDL
These interfaces don't currently have attributes, but they could have attributes that reflect the options/defaults passed into the constructor.
Conditions
WebIDL
Again, these interfaces don't have attributes, but they could reflect the options/defaults passed into the constructor.
Shortcuts
GET requests are the most common type of request to provide specific routing for.
WebIDL
Where the JavaScript implementation is roughly:
We may also consider treating strings as URL matchers.
router.add('/foo/')
===router.add(new RouterIfURL('/foo/'))
.router.add('/foo/*')
===router.add(new RouterIfURLPrefix('/foo/'))
.router.add('*.png')
===router.add(new RouterIfURLSuffix('.png'))
.Examples
Bypassing the service worker for particular resources
JavaScript
Offline-first
JavaScript
Online-first
JavaScript
Processing
This is very rough prose, but hopefully it explains the order of things.
A service worker has routes. The routes do not belong to the registration, so a new empty service worker will have no defined routes, even if the previous service worker defined many.
A route has items.
To create a new route containing items
Handling a fetch
These steps will come before handling navigation preload, meaning no preload will be made if a route handles the request.
request is the request being made.
RouterIfMethod
, then:RouterIfURL
, then:RouterSourceNetwork
, then:RouterSourceCache
, then:RouterSourceFetchEvent
, then:Extensibility
I can imagine things like:
RouterOr(...conditionalItems)
– True if any of the conditional items are true.RouterNot(condition)
– Inverts a condition.RouterIfResponse(options)
– Right now, a response is returned immediately once one is found. However, the route could continue, skipping sources, but processing conditions. This condition could check the response and break the route if it doesn't match. Along with a way to discard any selected response, you could discard responses that didn't have an ok status.RouterCacheResponse(cacheName)
– If a response has been found, add it to a cache.RouterCloneRequest()
– It feels likeRouterSourceNetwork
would consume requests, so if you need to do additional processing, this could clone the request.But these could arrive much later. Some of the things in the main proposal may also be considered "v2".
The text was updated successfully, but these errors were encountered: