elm-ssr 0.1.0
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/README.md +67 -0
- package/bin/elm-ssr.mjs +102 -0
- package/elm-src/ElmSsr/Action.elm +210 -0
- package/elm-src/ElmSsr/Document/Encode.elm +83 -0
- package/elm-src/ElmSsr/Document/Events.elm +125 -0
- package/elm-src/ElmSsr/Document.elm +26 -0
- package/elm-src/ElmSsr/Html/Attributes.elm +344 -0
- package/elm-src/ElmSsr/Html/Events.elm +95 -0
- package/elm-src/ElmSsr/Html.elm +706 -0
- package/elm-src/ElmSsr/Island/Shared.elm +38 -0
- package/elm-src/ElmSsr/Island.elm +49 -0
- package/elm-src/ElmSsr/Loader.elm +297 -0
- package/elm-src/ElmSsr/Page.elm +102 -0
- package/elm-src/ElmSsr/Route.elm +136 -0
- package/elm-src/ElmSsr/Runtime.elm +170 -0
- package/elm-src/ElmSsr/Svg/Attributes.elm +1208 -0
- package/elm-src/ElmSsr/Svg.elm +309 -0
- package/lib/build.mjs +256 -0
- package/lib/migrate.mjs +146 -0
- package/lib/scaffold.mjs +472 -0
- package/lib/workspace.mjs +21 -0
- package/package.json +60 -0
- package/src/app.ts +74 -0
- package/src/backends.ts +116 -0
- package/src/client-runtime/islands.ts +247 -0
- package/src/effects.ts +267 -0
- package/src/http.ts +86 -0
- package/src/middleware.ts +104 -0
- package/src/migrations.ts +225 -0
- package/src/protocol.ts +119 -0
- package/src/render.ts +111 -0
- package/src/request-handler.ts +208 -0
- package/src/response-headers.ts +18 -0
- package/src/serialize.ts +47 -0
- package/src/tasks.ts +139 -0
package/lib/scaffold.mjs
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { readWorkspaceConfig, writeWorkspaceConfig } from "./workspace.mjs";
|
|
4
|
+
|
|
5
|
+
const toWords = (value) =>
|
|
6
|
+
value
|
|
7
|
+
.split(/[^a-zA-Z0-9]+/)
|
|
8
|
+
.map((part) => part.trim())
|
|
9
|
+
.filter(Boolean);
|
|
10
|
+
|
|
11
|
+
const toPascalCase = (value) =>
|
|
12
|
+
toWords(value)
|
|
13
|
+
.map((part) => part[0].toUpperCase() + part.slice(1).toLowerCase())
|
|
14
|
+
.join("");
|
|
15
|
+
|
|
16
|
+
const ensureValidName = (name) => {
|
|
17
|
+
if (!/^[a-z0-9-]+$/.test(name)) {
|
|
18
|
+
throw new Error("Example name must use lowercase letters, numbers, and dashes only.");
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const ensureAppMissing = (config, name) => {
|
|
23
|
+
if (config.apps.some((app) => app.name === name)) {
|
|
24
|
+
throw new Error(`Example "${name}" already exists in elm-ssr.config.json.`);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const elmJsonTemplate = () => ({
|
|
29
|
+
type: "application",
|
|
30
|
+
"source-directories": [".elm-ssr", "src", ".elm-ssr/src"],
|
|
31
|
+
"elm-version": "0.19.1",
|
|
32
|
+
dependencies: {
|
|
33
|
+
direct: {
|
|
34
|
+
"elm/browser": "1.0.2",
|
|
35
|
+
"elm/core": "1.0.5",
|
|
36
|
+
"elm/html": "1.0.0",
|
|
37
|
+
"elm/json": "1.1.3",
|
|
38
|
+
"elm/url": "1.0.0"
|
|
39
|
+
},
|
|
40
|
+
indirect: {
|
|
41
|
+
"elm/time": "1.0.0",
|
|
42
|
+
"elm/virtual-dom": "1.0.3"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"test-dependencies": {
|
|
46
|
+
direct: {},
|
|
47
|
+
indirect: {}
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const sharedTemplate = (namespace) => `module ${namespace}.View.Shared exposing (head, shell)
|
|
52
|
+
|
|
53
|
+
import ElmSsr.Html exposing (Node, div, h1, text)
|
|
54
|
+
import ElmSsr.Html.Attributes exposing (class)
|
|
55
|
+
import ElmSsr.Page as Page
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
head : List (Node msg)
|
|
59
|
+
head =
|
|
60
|
+
[ Page.metaCharset "utf-8"
|
|
61
|
+
, Page.metaViewport "width=device-width, initial-scale=1"
|
|
62
|
+
, Page.stylesheet "/styles.css"
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
shell : String -> List (Node msg) -> Node msg
|
|
67
|
+
shell heading body =
|
|
68
|
+
div [ class "shell" ] (h1 [] [ text heading ] :: body)
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
const indexRouteTemplate = (namespace) => `module ${namespace}.Routes.Index exposing (page, action)
|
|
72
|
+
|
|
73
|
+
-- File-based routing: this module maps to GET /.
|
|
74
|
+
-- A page is stateless (no Model/Msg) and ships no client JavaScript.
|
|
75
|
+
|
|
76
|
+
import ElmSsr.Action as Action exposing (Action)
|
|
77
|
+
import ElmSsr.Document exposing (Document)
|
|
78
|
+
import ElmSsr.Html exposing (a, p, text)
|
|
79
|
+
import ElmSsr.Html.Attributes exposing (class, href)
|
|
80
|
+
import ElmSsr.Loader as Loader exposing (Loader)
|
|
81
|
+
import ElmSsr.Page as Page
|
|
82
|
+
import ElmSsr.Route exposing (Request)
|
|
83
|
+
import ${namespace}.View.Shared as Shared
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
page : Request -> Loader (Document Never)
|
|
87
|
+
page _ =
|
|
88
|
+
Loader.succeed view
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
action : Request -> Action (Document Never)
|
|
92
|
+
action _ =
|
|
93
|
+
Action.fail 405 "Method not allowed"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
view : Document Never
|
|
97
|
+
view =
|
|
98
|
+
Page.page
|
|
99
|
+
{ title = "Starter | elm-ssr"
|
|
100
|
+
, head = Shared.head
|
|
101
|
+
, body =
|
|
102
|
+
[ Shared.shell "elm-ssr starter"
|
|
103
|
+
[ p [] [ text "This page is stateless and renders on the edge with no client runtime." ]
|
|
104
|
+
, p [] [ a [ class "link", href "/counter" ] [ text "Open the interactive counter" ] ]
|
|
105
|
+
]
|
|
106
|
+
]
|
|
107
|
+
}
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
const counterRouteTemplate = (namespace) => `module ${namespace}.Routes.Counter exposing (page, action)
|
|
111
|
+
|
|
112
|
+
-- File-based routing: GET /counter. A static page that embeds an interactive
|
|
113
|
+
-- island. The page ships no client runtime; the browser mounts the island
|
|
114
|
+
-- separately, and only that root updates.
|
|
115
|
+
|
|
116
|
+
import ElmSsr.Action as Action exposing (Action)
|
|
117
|
+
import ElmSsr.Document exposing (Document)
|
|
118
|
+
import ElmSsr.Html exposing (p, text)
|
|
119
|
+
import ElmSsr.Loader as Loader exposing (Loader)
|
|
120
|
+
import ElmSsr.Page as Page
|
|
121
|
+
import ElmSsr.Route exposing (Request)
|
|
122
|
+
import ${namespace}.Islands.Counter as Counter
|
|
123
|
+
import ${namespace}.View.Shared as Shared
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
page : Request -> Loader (Document Never)
|
|
127
|
+
page _ =
|
|
128
|
+
Loader.succeed view
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
action : Request -> Action (Document Never)
|
|
132
|
+
action _ =
|
|
133
|
+
Action.fail 405 "Method not allowed"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
view : Document Never
|
|
137
|
+
view =
|
|
138
|
+
Page.page
|
|
139
|
+
{ title = "Counter | elm-ssr"
|
|
140
|
+
, head = Shared.head
|
|
141
|
+
, body =
|
|
142
|
+
[ Shared.shell "Counter"
|
|
143
|
+
[ p [] [ text "This page is static. Only the counter below is an interactive island." ]
|
|
144
|
+
, Counter.embed { start = 0 }
|
|
145
|
+
]
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
`;
|
|
149
|
+
|
|
150
|
+
const counterIslandTemplate = (namespace) => `module ${namespace}.Islands.Counter exposing
|
|
151
|
+
( embed
|
|
152
|
+
, Flags, Model, Msg
|
|
153
|
+
, encodeFlags
|
|
154
|
+
, init, main, subscriptions, update, view
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
-- A standard Browser.element island. The page only embeds a marker and props;
|
|
158
|
+
-- the browser mounts this module normally using Elm's own runtime.
|
|
159
|
+
|
|
160
|
+
import ElmSsr.Island as Island
|
|
161
|
+
import ElmSsr.Html as SsrHtml exposing (Node)
|
|
162
|
+
import ElmSsr.Html.Attributes as SsrAttributes
|
|
163
|
+
import Browser
|
|
164
|
+
import Html exposing (Html, button, div, span, text)
|
|
165
|
+
import Html.Attributes exposing (class, type_)
|
|
166
|
+
import Html.Events exposing (onClick)
|
|
167
|
+
import Json.Encode as Encode
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
embed : Flags -> Node msg
|
|
171
|
+
embed =
|
|
172
|
+
Island.embed "Counter"
|
|
173
|
+
{ encodeFlags = encodeFlags
|
|
174
|
+
, fallback = fallback
|
|
175
|
+
, id = Nothing
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
type alias Flags =
|
|
180
|
+
{ start : Int }
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
type alias Model =
|
|
184
|
+
{ count : Int }
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
type Msg
|
|
188
|
+
= Increment
|
|
189
|
+
| Decrement
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
encodeFlags : Flags -> Encode.Value
|
|
193
|
+
encodeFlags flags =
|
|
194
|
+
Encode.object [ ( "start", Encode.int flags.start ) ]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
fallback : Flags -> List (Node msg)
|
|
198
|
+
fallback flags =
|
|
199
|
+
[ SsrHtml.div [ SsrAttributes.class "counter fallback" ]
|
|
200
|
+
[ SsrHtml.span [ SsrAttributes.class "value" ] [ SsrHtml.text (String.fromInt flags.start) ] ]
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
main : Program Flags Model Msg
|
|
205
|
+
main =
|
|
206
|
+
Browser.element
|
|
207
|
+
{ init = init
|
|
208
|
+
, update = update
|
|
209
|
+
, view = view
|
|
210
|
+
, subscriptions = subscriptions
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
init : Flags -> ( Model, Cmd Msg )
|
|
215
|
+
init flags =
|
|
216
|
+
( { count = flags.start }, Cmd.none )
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
update : Msg -> Model -> ( Model, Cmd Msg )
|
|
220
|
+
update msg model =
|
|
221
|
+
case msg of
|
|
222
|
+
Increment ->
|
|
223
|
+
( { model | count = model.count + 1 }, Cmd.none )
|
|
224
|
+
|
|
225
|
+
Decrement ->
|
|
226
|
+
( { model | count = model.count - 1 }, Cmd.none )
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
subscriptions : Model -> Sub Msg
|
|
230
|
+
subscriptions _ =
|
|
231
|
+
Sub.none
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
view : Model -> Html Msg
|
|
235
|
+
view model =
|
|
236
|
+
div [ class "counter" ]
|
|
237
|
+
[ button [ class "button", type_ "button", onClick Decrement ] [ text "-" ]
|
|
238
|
+
, span [ class "value" ] [ text (String.fromInt model.count) ]
|
|
239
|
+
, button [ class "button primary", type_ "button", onClick Increment ] [ text "+" ]
|
|
240
|
+
]
|
|
241
|
+
`;
|
|
242
|
+
|
|
243
|
+
const notFoundRouteTemplate = (namespace) => `module ${namespace}.Routes.NotFound exposing (page, action)
|
|
244
|
+
|
|
245
|
+
-- File-based routing: NotFound is the fallback when no other route matches.
|
|
246
|
+
|
|
247
|
+
import ElmSsr.Action as Action exposing (Action)
|
|
248
|
+
import ElmSsr.Document exposing (Document)
|
|
249
|
+
import ElmSsr.Html exposing (p, text)
|
|
250
|
+
import ElmSsr.Loader as Loader exposing (Loader)
|
|
251
|
+
import ElmSsr.Page as Page
|
|
252
|
+
import ElmSsr.Route exposing (Request)
|
|
253
|
+
import ${namespace}.View.Shared as Shared
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
page : Request -> Loader (Document Never)
|
|
257
|
+
page _ =
|
|
258
|
+
Loader.succeed view
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
action : Request -> Action (Document Never)
|
|
262
|
+
action _ =
|
|
263
|
+
Action.fail 405 "Method not allowed"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
view : Document Never
|
|
267
|
+
view =
|
|
268
|
+
Page.notFound
|
|
269
|
+
{ title = "Not Found | elm-ssr"
|
|
270
|
+
, head = Shared.head
|
|
271
|
+
, body = [ Shared.shell "404" [ p [] [ text "This route does not exist." ] ] ]
|
|
272
|
+
}
|
|
273
|
+
`;
|
|
274
|
+
|
|
275
|
+
const runtimeTemplate = (name) => `import { createWorkerApp } from "elm-ssr";
|
|
276
|
+
import { renderApp, type CompiledElmModule } from "elm-ssr/render";
|
|
277
|
+
import type { RouteCatalog } from "elm-ssr/http";
|
|
278
|
+
import { islands, bundleSource } from "../../generated/examples/${name}/islands-manifest";
|
|
279
|
+
import { stylesheet } from "./styles";
|
|
280
|
+
// @ts-expect-error Generated at build time.
|
|
281
|
+
import ElmRuntime from "../../generated/examples/${name}/app.mjs";
|
|
282
|
+
|
|
283
|
+
const elmModule = ElmRuntime as CompiledElmModule;
|
|
284
|
+
|
|
285
|
+
export const routes: RouteCatalog = {
|
|
286
|
+
pages: [
|
|
287
|
+
{
|
|
288
|
+
path: "/",
|
|
289
|
+
methods: ["GET", "HEAD"],
|
|
290
|
+
description: "Stateless starter page rendered from Elm (no client runtime)."
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
path: "/counter",
|
|
294
|
+
methods: ["GET", "HEAD"],
|
|
295
|
+
description: "Interactive counter route rendered from Elm."
|
|
296
|
+
}
|
|
297
|
+
],
|
|
298
|
+
assets: [
|
|
299
|
+
{
|
|
300
|
+
path: "/styles.css",
|
|
301
|
+
methods: ["GET", "HEAD"],
|
|
302
|
+
description: "Starter stylesheet."
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
path: "/__elm-ssr/islands.js",
|
|
306
|
+
methods: ["GET", "HEAD"],
|
|
307
|
+
description: "Island loader runtime."
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
path: "/__elm-ssr/islands-bundle.js",
|
|
311
|
+
methods: ["GET", "HEAD"],
|
|
312
|
+
description: "Shared Browser.element island bundle."
|
|
313
|
+
}
|
|
314
|
+
],
|
|
315
|
+
utility: [
|
|
316
|
+
{
|
|
317
|
+
path: "/health",
|
|
318
|
+
methods: ["GET", "HEAD"],
|
|
319
|
+
description: "Plain text liveness endpoint."
|
|
320
|
+
}
|
|
321
|
+
],
|
|
322
|
+
api: [
|
|
323
|
+
{
|
|
324
|
+
path: "/api/health",
|
|
325
|
+
methods: ["GET", "HEAD"],
|
|
326
|
+
description: "JSON health payload."
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
path: "/api/routes",
|
|
330
|
+
methods: ["GET", "HEAD"],
|
|
331
|
+
description: "Route registry for the starter app."
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
path: "/api/render",
|
|
335
|
+
methods: ["GET", "HEAD"],
|
|
336
|
+
description: "SSR preview endpoint."
|
|
337
|
+
}
|
|
338
|
+
]
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
export const createFlags = ({ request, path, formData }: { request?: Request; url?: URL; path: string; formData?: Record<string, string> }) => {
|
|
342
|
+
const [pathname, search = ""] = path.split("?");
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
method: request?.method ?? "GET",
|
|
346
|
+
path: pathname,
|
|
347
|
+
query: Object.fromEntries(new URLSearchParams(search)),
|
|
348
|
+
formData: formData ?? {}
|
|
349
|
+
};
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
export const renderPath = async (path: string) =>
|
|
353
|
+
renderApp(elmModule, createFlags({ path }));
|
|
354
|
+
|
|
355
|
+
export const worker = createWorkerApp({
|
|
356
|
+
elmModule,
|
|
357
|
+
islands,
|
|
358
|
+
islandsBundle: bundleSource,
|
|
359
|
+
stylesheet,
|
|
360
|
+
routes,
|
|
361
|
+
createFlags
|
|
362
|
+
});
|
|
363
|
+
`;
|
|
364
|
+
|
|
365
|
+
const workerTemplate = () => `import { worker } from "./runtime";
|
|
366
|
+
|
|
367
|
+
export default worker;
|
|
368
|
+
`;
|
|
369
|
+
|
|
370
|
+
const stylesTemplate = () => `export const stylesheet = \`
|
|
371
|
+
:root {
|
|
372
|
+
color-scheme: light;
|
|
373
|
+
font-family: "IBM Plex Sans", sans-serif;
|
|
374
|
+
background: #f3efe7;
|
|
375
|
+
color: #18222f;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
body {
|
|
379
|
+
margin: 0;
|
|
380
|
+
background:
|
|
381
|
+
radial-gradient(circle at top, rgba(255, 255, 255, 0.85), transparent 45%),
|
|
382
|
+
linear-gradient(180deg, #f8f3ea 0%, #efe7dc 100%);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
.shell {
|
|
386
|
+
max-width: 44rem;
|
|
387
|
+
margin: 0 auto;
|
|
388
|
+
padding: 4rem 1.5rem;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
.counter {
|
|
392
|
+
display: grid;
|
|
393
|
+
grid-template-columns: auto 1fr auto;
|
|
394
|
+
gap: 0.75rem;
|
|
395
|
+
align-items: center;
|
|
396
|
+
margin: 2rem 0;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
.button,
|
|
400
|
+
.input {
|
|
401
|
+
border-radius: 999px;
|
|
402
|
+
border: 1px solid #18222f;
|
|
403
|
+
padding: 0.85rem 1.1rem;
|
|
404
|
+
font: inherit;
|
|
405
|
+
background: white;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
.button {
|
|
409
|
+
cursor: pointer;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.value {
|
|
413
|
+
text-align: center;
|
|
414
|
+
font-size: 2rem;
|
|
415
|
+
font-weight: 700;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.form {
|
|
419
|
+
margin-top: 1rem;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
.input {
|
|
423
|
+
width: 100%;
|
|
424
|
+
box-sizing: border-box;
|
|
425
|
+
}
|
|
426
|
+
\`;
|
|
427
|
+
`;
|
|
428
|
+
|
|
429
|
+
const filesForExample = (name) => {
|
|
430
|
+
const namespace = toPascalCase(name);
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
configEntry: {
|
|
434
|
+
name,
|
|
435
|
+
root: `examples/${name}`,
|
|
436
|
+
module: namespace
|
|
437
|
+
},
|
|
438
|
+
files: [
|
|
439
|
+
{ path: `examples/${name}/elm.json`, content: JSON.stringify(elmJsonTemplate(), null, 2) + "\n" },
|
|
440
|
+
{ path: `examples/${name}/runtime.ts`, content: runtimeTemplate(name) },
|
|
441
|
+
{ path: `examples/${name}/worker.ts`, content: workerTemplate() },
|
|
442
|
+
{ path: `examples/${name}/styles.ts`, content: stylesTemplate() },
|
|
443
|
+
{ path: `examples/${name}/src/${namespace}/View/Shared.elm`, content: sharedTemplate(namespace) },
|
|
444
|
+
{ path: `examples/${name}/src/${namespace}/Routes/Index.elm`, content: indexRouteTemplate(namespace) },
|
|
445
|
+
{ path: `examples/${name}/src/${namespace}/Routes/Counter.elm`, content: counterRouteTemplate(namespace) },
|
|
446
|
+
{ path: `examples/${name}/src/${namespace}/Routes/NotFound.elm`, content: notFoundRouteTemplate(namespace) },
|
|
447
|
+
{ path: `examples/${name}/src/${namespace}/Islands/Counter.elm`, content: counterIslandTemplate(namespace) }
|
|
448
|
+
]
|
|
449
|
+
};
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
export const createExampleScaffold = async (rootPath, name) => {
|
|
453
|
+
ensureValidName(name);
|
|
454
|
+
|
|
455
|
+
const config = await readWorkspaceConfig(rootPath);
|
|
456
|
+
ensureAppMissing(config, name);
|
|
457
|
+
|
|
458
|
+
const { configEntry, files } = filesForExample(name);
|
|
459
|
+
|
|
460
|
+
for (const file of files) {
|
|
461
|
+
const filePath = resolve(rootPath, file.path);
|
|
462
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
463
|
+
await writeFile(filePath, file.content, "utf8");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
await writeWorkspaceConfig(rootPath, {
|
|
467
|
+
...config,
|
|
468
|
+
apps: [...config.apps, configEntry]
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
return configEntry;
|
|
472
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const configFileName = "elm-ssr.config.json";
|
|
5
|
+
|
|
6
|
+
export const readJsonFile = async (filePath) =>
|
|
7
|
+
JSON.parse(await readFile(filePath, "utf8"));
|
|
8
|
+
|
|
9
|
+
export const writeJsonFile = async (filePath, value) => {
|
|
10
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
11
|
+
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const workspaceConfigPath = (rootPath) =>
|
|
15
|
+
resolve(rootPath, configFileName);
|
|
16
|
+
|
|
17
|
+
export const readWorkspaceConfig = async (rootPath) =>
|
|
18
|
+
readJsonFile(workspaceConfigPath(rootPath));
|
|
19
|
+
|
|
20
|
+
export const writeWorkspaceConfig = async (rootPath, config) =>
|
|
21
|
+
writeJsonFile(workspaceConfigPath(rootPath), config);
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "elm-ssr",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Elm-first SSR library and framework for Cloudflare Workers (and Bun): file-based routes/islands, backend-neutral effect adapters (KV/D1/Redis/Postgres), background tasks (waitUntil/Queues), SQL-file migrations, CLI scaffold + build.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Michał Majchrzak <michmajchrzak@gmail.com>",
|
|
7
|
+
"homepage": "https://github.com/the-man-with-a-golden-mind/elm-ssr#readme",
|
|
8
|
+
"bugs": {
|
|
9
|
+
"url": "https://github.com/the-man-with-a-golden-mind/elm-ssr/issues"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/the-man-with-a-golden-mind/elm-ssr.git",
|
|
14
|
+
"directory": "packages/elm-ssr"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"elm",
|
|
18
|
+
"ssr",
|
|
19
|
+
"server-side-rendering",
|
|
20
|
+
"cloudflare-workers",
|
|
21
|
+
"bun",
|
|
22
|
+
"islands",
|
|
23
|
+
"framework",
|
|
24
|
+
"edge",
|
|
25
|
+
"file-based-routing",
|
|
26
|
+
"migrations"
|
|
27
|
+
],
|
|
28
|
+
"type": "module",
|
|
29
|
+
"scripts": {},
|
|
30
|
+
"bin": {
|
|
31
|
+
"elm-ssr": "bin/elm-ssr.mjs"
|
|
32
|
+
},
|
|
33
|
+
"main": "./src/app.ts",
|
|
34
|
+
"exports": {
|
|
35
|
+
".": "./src/app.ts",
|
|
36
|
+
"./render": "./src/render.ts",
|
|
37
|
+
"./http": "./src/http.ts",
|
|
38
|
+
"./effects": "./src/effects.ts",
|
|
39
|
+
"./tasks": "./src/tasks.ts",
|
|
40
|
+
"./backends": "./src/backends.ts",
|
|
41
|
+
"./migrations": "./src/migrations.ts",
|
|
42
|
+
"./middleware": "./src/middleware.ts",
|
|
43
|
+
"./response-headers": "./src/response-headers.ts",
|
|
44
|
+
"./serialize": "./src/serialize.ts",
|
|
45
|
+
"./protocol": "./src/protocol.ts",
|
|
46
|
+
"./islands-runtime": "./src/client-runtime/islands.ts"
|
|
47
|
+
},
|
|
48
|
+
"files": [
|
|
49
|
+
"bin",
|
|
50
|
+
"lib",
|
|
51
|
+
"elm-src",
|
|
52
|
+
"src"
|
|
53
|
+
],
|
|
54
|
+
"engines": {
|
|
55
|
+
"bun": ">=1.3"
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"cookie": "^1.0.1"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RenderFlagsFactory,
|
|
3
|
+
RouteCatalog,
|
|
4
|
+
WorkerExecutionContext,
|
|
5
|
+
WorkerHandler
|
|
6
|
+
} from "./http";
|
|
7
|
+
import {
|
|
8
|
+
composeMiddleware,
|
|
9
|
+
errorMiddleware,
|
|
10
|
+
headMiddleware,
|
|
11
|
+
loggingMiddleware,
|
|
12
|
+
requestIdMiddleware,
|
|
13
|
+
timingMiddleware
|
|
14
|
+
} from "./middleware";
|
|
15
|
+
import { type EffectRunner } from "./effects";
|
|
16
|
+
import { createRequestHandler } from "./request-handler";
|
|
17
|
+
import { type CompiledElmModule } from "./render";
|
|
18
|
+
|
|
19
|
+
export interface IslandMetadata {
|
|
20
|
+
module: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface WorkerAppOptions {
|
|
24
|
+
elmModule: CompiledElmModule;
|
|
25
|
+
islands?: Record<string, IslandMetadata>;
|
|
26
|
+
islandsBundle?: string;
|
|
27
|
+
stylesheet: string;
|
|
28
|
+
routes: RouteCatalog;
|
|
29
|
+
createFlags: RenderFlagsFactory;
|
|
30
|
+
effects?: EffectRunner;
|
|
31
|
+
log?: (entry: string) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const createWorkerApp = ({
|
|
35
|
+
elmModule,
|
|
36
|
+
islands,
|
|
37
|
+
islandsBundle,
|
|
38
|
+
stylesheet,
|
|
39
|
+
routes,
|
|
40
|
+
createFlags,
|
|
41
|
+
effects,
|
|
42
|
+
log
|
|
43
|
+
}: WorkerAppOptions): WorkerHandler => {
|
|
44
|
+
const handler = createRequestHandler({
|
|
45
|
+
elmModule,
|
|
46
|
+
islands,
|
|
47
|
+
islandsBundle,
|
|
48
|
+
stylesheet,
|
|
49
|
+
routes,
|
|
50
|
+
createFlags,
|
|
51
|
+
effects
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const appHandler = composeMiddleware(handler, [
|
|
55
|
+
errorMiddleware,
|
|
56
|
+
requestIdMiddleware,
|
|
57
|
+
timingMiddleware,
|
|
58
|
+
loggingMiddleware(log),
|
|
59
|
+
headMiddleware
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
fetch(request: Request, env?: unknown, executionCtx?: WorkerExecutionContext) {
|
|
64
|
+
return appHandler({
|
|
65
|
+
request,
|
|
66
|
+
url: new URL(request.url),
|
|
67
|
+
requestId: "",
|
|
68
|
+
startedAt: performance.now(),
|
|
69
|
+
executionCtx,
|
|
70
|
+
env: (env ?? undefined) as Record<string, unknown> | undefined
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
};
|