@inkandswitch/patchwork-bootloader 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # @inkandswitch/patchwork-bootloader
2
+
3
+ ## 0.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 33681ef: initial release
8
+
9
+ making the packages available for the first time on npm
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@inkandswitch/patchwork-bootloader",
3
+ "version": "0.0.1",
4
+ "author": "chee",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "devDependencies": {
8
+ "@automerge/automerge-repo-keyhive": "0.0.0-alpha.71",
9
+ "@keyhive/keyhive": "0.0.0-alpha.40",
10
+ "esbuild": "^0.23.1",
11
+ "rollup": "^4.53.3"
12
+ },
13
+ "exports": {
14
+ ".": {
15
+ "import": "./dist/setup.js",
16
+ "types": "./dist/setup.d.ts"
17
+ },
18
+ "./externals": {
19
+ "import": "./dist/externals.js",
20
+ "types": "./dist/externals.d.ts"
21
+ },
22
+ "./vite": {
23
+ "import": "./dist/vite/patchwork-plugin.js",
24
+ "types": "./dist/vite/patchwork-plugin.d.ts"
25
+ },
26
+ "./service-worker": {
27
+ "import": "./dist/service-worker.js",
28
+ "types": "./dist/service-worker.d.ts"
29
+ },
30
+ "./types": {
31
+ "import": "./dist/types.js",
32
+ "types": "./dist/types.d.ts"
33
+ }
34
+ },
35
+ "dependencies": {
36
+ "@automerge/automerge": "3.2.1",
37
+ "@automerge/automerge-repo": "2.5.0",
38
+ "@automerge/vanillajs": "2.5.0",
39
+ "@types/debug": "^4.1.12",
40
+ "debug": "^4.4.3",
41
+ "resolve.exports": "^2.0.3",
42
+ "service-worker-types": "npm:@types/serviceworker@^0.0.153",
43
+ "tinyargs": "^0.1.4"
44
+ },
45
+ "peerDependencies": {
46
+ "@automerge/automerge": "3.2.1",
47
+ "@automerge/automerge-repo": "2.5.0",
48
+ "@automerge/automerge-repo-keyhive": "0.0.0-alpha.71",
49
+ "@automerge/vanillajs": "2.5.0",
50
+ "@keyhive/keyhive": "0.0.0-alpha.40"
51
+ },
52
+ "scripts": {
53
+ "build": "tsc",
54
+ "dev": "tsc -w --preserveWatchOutput"
55
+ }
56
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * these dependencies will be built into the outdir, and injected into the importmap
3
+ */
4
+ const externals = [
5
+ "@automerge/automerge",
6
+ "@automerge/automerge/slim",
7
+ "@automerge/automerge-repo",
8
+ "@automerge/automerge-repo/slim",
9
+ "@automerge/automerge-repo-keyhive",
10
+ "@keyhive/keyhive",
11
+ "@keyhive/keyhive/slim",
12
+ "@inkandswitch/patchwork-bootloader",
13
+ "@inkandswitch/patchwork-elements",
14
+ "@inkandswitch/patchwork-filesystem",
15
+ "@inkandswitch/patchwork-plugins",
16
+
17
+ // sad
18
+ "@codemirror/state",
19
+ "@codemirror/view",
20
+ ];
21
+ export default externals;
@@ -0,0 +1,187 @@
1
+ /// <reference types="service-worker-types" />
2
+
3
+ import type { HandoffResponse, HandoffResponseMessage } from "./types.js";
4
+
5
+ let cachename = "default";
6
+ let debugging = false;
7
+
8
+ function log(...args: any[]) {
9
+ if (!debugging) return;
10
+ console.log.call(
11
+ console,
12
+ `%cpatchwork:serviceworker%c\n`,
13
+ `color: #00ffcc; font-weight: bold`,
14
+ "color: inherit",
15
+ ...args
16
+ );
17
+ }
18
+
19
+ self.addEventListener("install", () => self.skipWaiting());
20
+
21
+ async function clearOldCaches() {
22
+ const cacheWhitelist = [cachename];
23
+ const cacheNames = await caches.keys();
24
+ const deletePromises = cacheNames.map((cacheName) => {
25
+ if (!cacheWhitelist.includes(cacheName)) {
26
+ return caches.delete(cacheName);
27
+ }
28
+ });
29
+ await Promise.all(deletePromises);
30
+ }
31
+
32
+ self.addEventListener("activate", async () => {
33
+ await clearOldCaches();
34
+ clients.claim();
35
+ });
36
+
37
+ // a map of response promises by their id
38
+ const responseResolvers = new Map<
39
+ number,
40
+ PromiseWithResolvers<HandoffResponse>
41
+ >();
42
+
43
+ function accept(message: HandoffResponseMessage) {
44
+ const responseItem = responseResolvers.get(message.id);
45
+ if (!responseItem) {
46
+ return console.warn(`No read response found for id ${message.id}`);
47
+ }
48
+ return responseItem.resolve(message.response);
49
+ }
50
+
51
+ const bc = new BroadcastChannel("@patchwork/handoff");
52
+
53
+ bc.addEventListener("message", (event) => {
54
+ if (event.data.type == "response") accept(event.data);
55
+ });
56
+
57
+ // when we receive a `response` req, we resolve the promise with that id
58
+ self.addEventListener("message", async (event) => {
59
+ if (event.data.type == "response") {
60
+ accept(event.data);
61
+ } else if (event.data.type == "port") {
62
+ log("recieved messagechannel");
63
+ const [port] = event.ports;
64
+ port.addEventListener("message", (event) => {
65
+ if (event.data.type == "response") {
66
+ accept(event.data);
67
+ }
68
+ });
69
+ } else if (event.data.type == "cachename") {
70
+ const nextCachename = event.data.cachename;
71
+ if (cachename == nextCachename) {
72
+ return;
73
+ }
74
+ console.info(
75
+ `deleting ${cachename} and setting cache name to ${nextCachename}`
76
+ );
77
+ caches.delete(cachename);
78
+ cachename = nextCachename;
79
+ } else if (event.data.type == "debug") {
80
+ debugging = event.data.debug;
81
+ log("serviceworker debugging enabled");
82
+ }
83
+ });
84
+
85
+ // request ids are kept in a counter
86
+ let reqcount = 0;
87
+ self.addEventListener("fetch", async (fetchEvent: FetchEvent) => {
88
+ const request = fetchEvent.request;
89
+ if (request.method !== "GET") return fetchEvent.respondWith(fetch(request));
90
+ const url = new URL(fetchEvent.request.url);
91
+
92
+ let handoffURL: URL | undefined;
93
+
94
+ if (
95
+ url.hostname == self.location.hostname &&
96
+ url.port == self.location.port &&
97
+ url.protocol == self.location.protocol
98
+ ) {
99
+ try {
100
+ // trap any request like /url e.g. /automerge%3Awhatever or /http%3A%2F%2Fsomething.com
101
+ // URI encoded so we can include hashes etc
102
+ handoffURL = new URL(decodeURIComponent(url.pathname.slice(1)));
103
+ log(`received handoff request ${handoffURL}`);
104
+ } catch {}
105
+ }
106
+
107
+ fetchEvent.respondWith(
108
+ (async () => {
109
+ const cache = await caches.open(cachename);
110
+ const match = await cache.match(request);
111
+
112
+ try {
113
+ if (handoffURL) {
114
+ // cache-first strategy for handoff requests
115
+ if (match) return match;
116
+ const client = await self.clients.get(fetchEvent.clientId);
117
+
118
+ // set up a request id
119
+ const reqid = reqcount++;
120
+ // create a place for the response event handler to put the response
121
+ const resolvers = Promise.withResolvers<HandoffResponse>();
122
+ responseResolvers.set(reqid, resolvers);
123
+
124
+ // i don't think this can happen
125
+ if (!client) {
126
+ throw new Error(
127
+ `the client has gone missing!!! ${fetchEvent.clientId}. i have NO IDEA what to do`
128
+ );
129
+ }
130
+ const message = {
131
+ id: reqid,
132
+ type: "request",
133
+ cache: cachename,
134
+ request: {
135
+ url: handoffURL.href,
136
+ headers: Object.fromEntries(request.headers.entries()),
137
+ method: request.method,
138
+ destination: request.destination,
139
+ referrer: request.referrer,
140
+ },
141
+ };
142
+ log("sending handoff request", message);
143
+ // send request event to main thread to ask them how to handle it
144
+ client.postMessage(message);
145
+ // this'll finish when the main thread gets back to us
146
+ fetchEvent.waitUntil(resolvers.promise);
147
+ const handoffResponse = await resolvers.promise;
148
+ log("received handoff response", handoffResponse);
149
+ if (handoffResponse) {
150
+ const response = new Response(handoffResponse.body, {
151
+ status: handoffResponse.status,
152
+ headers: handoffResponse.headers,
153
+ });
154
+ if (handoffResponse.cache !== false) {
155
+ log(`caching ${handoffURL}`);
156
+ await cache.put(request, response.clone());
157
+ } else {
158
+ log(`caching disabled on ${handoffURL}`);
159
+ }
160
+ return response;
161
+ }
162
+
163
+ // no idea what's going on now i'm a teapot i'm a teapot
164
+ return new Response("handler returned nothing", {
165
+ status: 418,
166
+ });
167
+ } else {
168
+ // network first strategy for external requests
169
+ const response = await fetch(request);
170
+ if (response) {
171
+ if (response.ok && response.url.match(/^https?\:/)) {
172
+ await cache.put(request, response.clone());
173
+ }
174
+ return response;
175
+ }
176
+ if (match) return match;
177
+ return new Response("couldnt fetch and no stale", { status: 503 });
178
+ }
179
+ } catch (error) {
180
+ if (match) return match;
181
+ // if something fucked up happens, serve a stale thing if there is one
182
+ // probably can do better error messaging here based on what was caught
183
+ return new Response(`yikes: ${error}`, { status: 555 });
184
+ }
185
+ })()
186
+ );
187
+ });
package/src/setup.ts ADDED
@@ -0,0 +1,131 @@
1
+ import type {
2
+ HandoffHandler,
3
+ HandoffRequestMessage,
4
+ HandoffResponse,
5
+ SetupServiceWorkerOptions,
6
+ } from "./types.js";
7
+ import debug from "debug";
8
+
9
+ const debugging = debug.enabled("patchwork:serviceworker");
10
+
11
+ const key = "patchworkServiceWorkerCacheVersion";
12
+
13
+ function bumpServiceWorkerCacheVersion() {
14
+ const version = new Date().valueOf().toString(36);
15
+ localStorage.setItem(key, version);
16
+ return getServiceWorkerCacheVersion();
17
+ }
18
+
19
+ function getServiceWorkerCacheVersion() {
20
+ return localStorage.getItem(key);
21
+ }
22
+
23
+ function getOrCreateServiceWorkerCacheVersion() {
24
+ const existing = getServiceWorkerCacheVersion();
25
+ if (existing) return existing;
26
+ return bumpServiceWorkerCacheVersion();
27
+ }
28
+
29
+ function setServiceWorkerCacheName(sw: ServiceWorker | null) {
30
+ if (!sw) {
31
+ throw new Error("no service worker!");
32
+ }
33
+ sw.postMessage({
34
+ type: "cachename",
35
+ cachename: getOrCreateServiceWorkerCacheVersion(),
36
+ });
37
+ }
38
+
39
+ export function bumpServiceWorkerCache(
40
+ sw = navigator.serviceWorker.controller
41
+ ) {
42
+ bumpServiceWorkerCacheVersion();
43
+ setServiceWorkerCacheName(sw);
44
+ }
45
+
46
+ export default async function setupServiceWorker(
47
+ handler: HandoffHandler,
48
+ options?: SetupServiceWorkerOptions
49
+ ) {
50
+ navigator.serviceWorker.addEventListener("controllerchange", function () {
51
+ console.log(
52
+ "%cnew service worker, reloading",
53
+ "color: pink; font-weight: bold"
54
+ );
55
+ bumpServiceWorkerCache(navigator.serviceWorker.controller);
56
+ location.reload();
57
+ });
58
+
59
+ navigator.serviceWorker.addEventListener("message", async (event) => {
60
+ if (event.data.type == "request") {
61
+ const requestMessage: HandoffRequestMessage = event.data;
62
+ const source = event.source;
63
+
64
+ if (!source) {
65
+ throw new TypeError("can't operate without a source");
66
+ }
67
+
68
+ async function send(
69
+ response: HandoffResponse,
70
+ transfer?: Transferable[]
71
+ ) {
72
+ source!.postMessage(
73
+ {
74
+ id: requestMessage.id,
75
+ type: "response",
76
+ response,
77
+ },
78
+ { transfer }
79
+ );
80
+ }
81
+
82
+ const handoffResponse = await handler(
83
+ requestMessage.request.url,
84
+ requestMessage.request
85
+ );
86
+
87
+ if (!handoffResponse) {
88
+ return source?.postMessage({ id: requestMessage.id, type: "response" });
89
+ }
90
+
91
+ if (typeof handoffResponse == "string") {
92
+ return send({ body: handoffResponse }, [handoffResponse]);
93
+ }
94
+
95
+ if (handoffResponse instanceof Uint8Array) {
96
+ return send({ body: handoffResponse }, [handoffResponse.buffer]);
97
+ }
98
+
99
+ const { body: handoffBody, headers, status, cache } = handoffResponse;
100
+
101
+ const body = handoffBody;
102
+
103
+ send(
104
+ { body, headers, status, cache },
105
+ body instanceof Uint8Array ? [body.buffer] : undefined
106
+ );
107
+ }
108
+ });
109
+
110
+ const existingSw = await navigator.serviceWorker.getRegistration();
111
+
112
+ return navigator.serviceWorker
113
+ .register(options?.path ?? "/service-worker.js")
114
+ .then(async (sw) => {
115
+ sw.active?.postMessage({
116
+ type: "debug",
117
+ debug: debugging,
118
+ });
119
+
120
+ if (!existingSw?.active) {
121
+ bumpServiceWorkerCache(sw.installing);
122
+ queueMicrotask(() => location.reload());
123
+ return sw.active!;
124
+ }
125
+
126
+ console.log(
127
+ "service worker alive, loading %c patchwork system ",
128
+ "background: #fff8f0; border: 1px solid; border-radius: 4px"
129
+ );
130
+ });
131
+ }
package/src/types.ts ADDED
@@ -0,0 +1,42 @@
1
+ export interface HandoffRequest {
2
+ url: string;
3
+ headers: Record<string, string>;
4
+ method: string;
5
+ destination: RequestDestination;
6
+ referrer: string;
7
+ }
8
+
9
+ export interface HandoffResponse {
10
+ body?: string | Uint8Array<ArrayBuffer> | ReadableStream;
11
+ /** defaults to 200 */
12
+ status?: number;
13
+ headers?: [string, string][] | Record<string, string>;
14
+ cache?: boolean;
15
+ }
16
+
17
+ export interface HandoffRequestMessage {
18
+ id: number;
19
+ type: "request";
20
+ /** the current name of the service worker cache */
21
+ cachename: string;
22
+ request: HandoffRequest;
23
+ }
24
+
25
+ export interface HandoffResponseMessage {
26
+ id: number;
27
+ type: "response";
28
+ response: HandoffResponse;
29
+ }
30
+
31
+ export type HandoffHandler = (
32
+ href: string,
33
+ request: HandoffRequest
34
+ ) => Promise<HandoffResponse | void | string | Uint8Array<ArrayBuffer>>;
35
+
36
+ export type SetupServiceWorkerOptions = {
37
+ /**
38
+ * The public path to the service worker file.
39
+ * Defaults to `/service-worker.js`
40
+ */
41
+ path?: string;
42
+ };
@@ -0,0 +1,75 @@
1
+ import type { Plugin } from "vite";
2
+ import type {
3
+ ImportMap,
4
+ PatchworkVitePluginOptions,
5
+ } from "./patchwork-plugin.js";
6
+
7
+ /**
8
+ * these dependencies will be built into the outdir,
9
+ * and injected into the importmap
10
+ */
11
+ import externals from "../externals.js";
12
+
13
+ export const builtins = externals.reduce(
14
+ (builtins, name) => ((builtins[name] = `/packages/${name}.js`), builtins),
15
+ {} as Record<string, string>
16
+ );
17
+
18
+ /**
19
+ * merge the importmap option with our builtins
20
+ */
21
+ function createImportMap(options?: PatchworkVitePluginOptions) {
22
+ const importmap: ImportMap = structuredClone(
23
+ options?.importmap ?? { imports: {}, scopes: {} }
24
+ );
25
+ importmap.imports ??= {};
26
+ importmap.scopes ??= {};
27
+ Object.assign(importmap.imports, builtins);
28
+ return { importmap, builtins };
29
+ }
30
+
31
+ export function importmap(options?: PatchworkVitePluginOptions): Plugin {
32
+ const { importmap, builtins } = createImportMap(options);
33
+ return {
34
+ name: "@patchwork/vite",
35
+ async buildStart() {
36
+ if (this.environment.mode == "build") {
37
+ for (const [id, fileName] of Object.entries(builtins)) {
38
+ this.emitFile({
39
+ type: "chunk",
40
+ fileName: fileName.slice(1),
41
+ id,
42
+ preserveSignature: "strict",
43
+ });
44
+ }
45
+ }
46
+ },
47
+ resolveId(id) {
48
+ if (id in importmap.imports && !(id in builtins)) {
49
+ return { id: importmap.imports[id], external: true };
50
+ }
51
+ },
52
+ transformIndexHtml: {
53
+ order: "pre",
54
+ handler(html, ctx) {
55
+ const map = structuredClone(importmap);
56
+ if (ctx.server) {
57
+ // serve builtins from dev server in dev mode
58
+ for (const id of Object.keys(builtins)) {
59
+ map.imports[id] = `/@id/${id}`;
60
+ }
61
+ }
62
+ return {
63
+ html,
64
+ tags: [
65
+ {
66
+ tag: "script",
67
+ attrs: { type: "importmap" },
68
+ children: JSON.stringify(map, null, 2),
69
+ },
70
+ ],
71
+ };
72
+ },
73
+ },
74
+ };
75
+ }
@@ -0,0 +1,20 @@
1
+ // todo this is now not patchwork-specific, it's just a fun little importmap thing
2
+ // and a separate thing that returns the service worker which doesn't really
3
+ // need to be a plugin at all
4
+
5
+ import { importmap } from "./importmap-plugin.js";
6
+ import { serviceworker } from "./service-worker-plugin.js";
7
+
8
+ export default function patchwork(options?: PatchworkVitePluginOptions) {
9
+ return [importmap(options), serviceworker()];
10
+ }
11
+
12
+ type Imports = { [name: string]: string };
13
+ export type ImportMap = {
14
+ imports: Imports;
15
+ scopes?: { [scope: string]: Imports };
16
+ };
17
+
18
+ export interface PatchworkVitePluginOptions {
19
+ importmap?: ImportMap;
20
+ }
@@ -0,0 +1,48 @@
1
+ import { type Plugin, transformWithEsbuild } from "vite";
2
+ import { type ResolvedId, type RollupFsModule } from "rollup";
3
+
4
+ export function serviceworker(): Plugin {
5
+ const moduleId = "service-worker.js";
6
+ const path = `/${moduleId}`;
7
+ const ids = [moduleId, path];
8
+ const serviceWorkerExport = "@inkandswitch/patchwork-bootloader/service-worker";
9
+ async function transform(
10
+ resolve: (source: string) => Promise<ResolvedId | null>,
11
+ fs: RollupFsModule
12
+ ) {
13
+ const exportPath = await resolve(serviceWorkerExport);
14
+ const file = await fs.readFile(exportPath!.id, {
15
+ encoding: "utf8",
16
+ });
17
+ const transformation = await transformWithEsbuild(
18
+ file,
19
+ serviceWorkerExport,
20
+ { format: "iife" }
21
+ );
22
+ return transformation;
23
+ }
24
+ return {
25
+ name: "@patchwork/vite",
26
+ async buildStart() {
27
+ if (this.environment.mode == "build") {
28
+ const trans = await transform(this.resolve.bind(this), this.fs);
29
+ this.emitFile({
30
+ type: "prebuilt-chunk",
31
+ fileName: path.slice(1),
32
+ code: trans.code,
33
+ map: trans.map,
34
+ });
35
+ }
36
+ },
37
+ resolveId(id) {
38
+ if (ids.includes(id)) {
39
+ return moduleId;
40
+ }
41
+ },
42
+ async load(id) {
43
+ if (ids.includes(id)) {
44
+ return transform(this.resolve.bind(this), this.fs);
45
+ }
46
+ },
47
+ };
48
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "esnext",
4
+ "module": "esnext",
5
+ "moduleResolution": "bundler",
6
+ "outDir": "dist",
7
+ "declaration": true,
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "isolatedModules": true
11
+ },
12
+ "include": ["src", "../../sites/tiny-patchwork/shared-worker.ts"]
13
+ }